layout.tsx 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. import { ReactNode } from 'react';
  2. import Link from 'next/link';
  3. import { permanentRedirect } from 'next/navigation';
  4. import { fetchJson } from '@/lib/utils/server';
  5. import { ResultDto } from '@/types/response/common';
  6. import { ChannelDetail } from '@/types/channel';
  7. import { buildChannelUrl, buildWatchUrl, formatHandle } from '@/lib/utils/channel';
  8. import FollowButton from '@/app/component/FollowButton';
  9. import NoteButton from '@/app/(main)/note/_components/NoteButton';
  10. import PoweredByYouTube from '@/app/component/PoweredByYouTube';
  11. import ChannelTabs from './_component/ChannelTabs';
  12. import ChannelNotFound from './_component/ChannelNotFound';
  13. import './style.scss';
  14. type Props = {
  15. children: ReactNode;
  16. params: Promise<{ identifier: string }>;
  17. };
  18. function formatCount(n: number): string {
  19. if (n >= 10000) {
  20. return `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}만`;
  21. }
  22. if (n >= 1000) {
  23. return `${(n / 1000).toFixed(1)}천`;
  24. }
  25. return n.toLocaleString();
  26. }
  27. export default async function ChannelLayout({ children, params }: Props)
  28. {
  29. const { identifier } = await params;
  30. const decoded = decodeURIComponent(identifier);
  31. const res: ResultDto<ChannelDetail> = await fetchJson(`/api/channel/${encodeURIComponent(decoded)}`, {
  32. method: 'GET'
  33. });
  34. if (!res.data) {
  35. return <ChannelNotFound />;
  36. }
  37. const ch = res.data;
  38. // canonical URL 리다이렉트: 현재 URL 파라미터와 실제 채널의 canonical 핸들이 다르면 301
  39. const canonical = buildChannelUrl(ch);
  40. const current = `/channel/${decoded}`;
  41. if (current !== canonical) {
  42. permanentRedirect(canonical);
  43. }
  44. return (
  45. <div className="channel-page">
  46. {/* 배너 */}
  47. <div className="channel-page__banner">
  48. {ch.bannerUrl && (
  49. <img
  50. src={`${ch.bannerUrl}=w1707-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj`}
  51. alt={`${ch.name} 배너`}
  52. className="channel-page__banner-img"
  53. />
  54. )}
  55. </div>
  56. {/* 프로필 */}
  57. <div className="channel-page__profile">
  58. {/* 썸네일 */}
  59. <a href={ch.youTubeUrl} target="_blank" rel="noopener noreferrer" className={`channel-page__avatar${ch.isLive ? ' channel-page__avatar--live' : ''}`}>
  60. {ch.thumbnailUrl ? (
  61. <img src={ch.thumbnailUrl} alt={ch.name} />
  62. ) : (
  63. <div className="channel-page__avatar-placeholder">{ch.name.charAt(0)}</div>
  64. )}
  65. {ch.isLive && <span className="channel-page__live-badge">LIVE</span>}
  66. </a>
  67. {/* 채널명 + 메타 */}
  68. <div className="channel-page__info">
  69. <h1 className="channel-page__name">
  70. <a href={ch.youTubeUrl} target="_blank" rel="noopener noreferrer">{ch.name}</a>
  71. {ch.isVerified && <span className="channel-page__verified" title="인증됨">✓</span>}
  72. </h1>
  73. <div className="channel-page__meta">
  74. {ch.handle && <span>{formatHandle(ch.handle)}</span>}
  75. {(ch.subscriberCount ?? 0) > 0 && <span>구독자 {formatCount(ch.subscriberCount)}명</span>}
  76. {(ch.videoCount ?? 0) > 0 && <span>동영상 {ch.videoCount.toLocaleString()}개</span>}
  77. <PoweredByYouTube />
  78. </div>
  79. {/* 데스크톱 전용: 메타 아래 인라인 */}
  80. <div className="channel-page__buttons channel-page__buttons--desktop">
  81. <a href={ch.youTubeUrl} target="_blank" rel="noopener noreferrer" className="channel-page__action channel-page__subscribe-btn">구독</a>
  82. <FollowButton memberSID={ch.memberSID} className="channel-page__action channel-page__follow" />
  83. <NoteButton
  84. target={{ memberID: ch.memberID, displayName: ch.name, thumbnailUrl: ch.thumbnailUrl }}
  85. className="channel-page__action channel-page__note-btn"
  86. />
  87. </div>
  88. </div>
  89. </div>
  90. {/* 모바일 전용: 프로필 아래 별도 줄 */}
  91. <div className="channel-page__buttons channel-page__buttons--mobile mt-3">
  92. <a href={ch.youTubeUrl} target="_blank" rel="noopener noreferrer" className="channel-page__action channel-page__subscribe-btn">구독</a>
  93. <FollowButton memberSID={ch.memberSID} className="channel-page__action channel-page__follow" />
  94. <NoteButton
  95. target={{ memberID: ch.memberID, displayName: ch.name, thumbnailUrl: ch.thumbnailUrl }}
  96. className="channel-page__action channel-page__note-btn"
  97. />
  98. </div>
  99. {/* 라이브 상태 */}
  100. {ch.isLive && (
  101. <div className="channel-page__live">
  102. <span className="channel-page__live-dot" />
  103. <span className="channel-page__live-title">{ch.liveTitle}</span>
  104. <Link href={buildWatchUrl(ch)} className="channel-page__watch-btn">방송 보러가기 →</Link>
  105. </div>
  106. )}
  107. {/* 탭 네비게이션 */}
  108. <ChannelTabs identifier={decoded} />
  109. {/* 탭 콘텐츠 */}
  110. {children}
  111. </div>
  112. );
  113. }